Skip to content

Live file-watching for external edits (desktop)#13

Merged
resure merged 4 commits into
mainfrom
live-file-watching
Jul 6, 2026
Merged

Live file-watching for external edits (desktop)#13
resure merged 4 commits into
mainfrom
live-file-watching

Conversation

@resure

@resure resure commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What

The desktop app now picks up external changes to the notes folder live — another app, another window, a sync agent, a shell script — without waiting for the window to regain focus. Edits show up in the list within ~1 s, and an external change to the open note raises the conflict banner immediately. The web builds keep the focus-driven refresh and gain a manual Reload notes item in the orb menu.

Also rides along: a docs restructure (README slimmed to a feature list + two-path Getting started; new docs/architecture.md with the on-disk format and known limitations, and docs/shortcuts.md with the full shortcut sheet).

How

  • Rust (src-tauri/src/lib.rs): one debounced notify/FSEvents watcher per open folder (notes_watch/notes_unwatch + a Watchers state), shared by every window on that folder via per-window-label refcounted subscriptions (StrictMode makes watch, watch, unwatch a legal wire order). Emits notes:changed {dir, paths} per subscriber; dir stays the raw frontend path (strict-equals TauriNoteStore.dir) while path filtering runs against the canonicalized root (FSEvents resolves /var/private/var). The filter passes .md leaves, existing dirs (a Finder folder rename reports only dir paths), and vanished paths; noise (dot dirs, sidecar, Attachments/, write temps) is dropped at the source. >64 paths, a watcher error, or a watch-root event (FSEvents' overflow rescan) → empty paths = "refresh everything".
  • Seam: optional NoteStore.watch?(onChange) (like reveal?), implemented only by TauriNoteStore — listen-before-invoke so no event slips the gap, sync idempotent disposer.
  • useNotes: a ready-gated watch effect — 300 ms trailing debounce, in-flight coalescing, conflict-check-then-refresh (reconcile nulls a vanished active, so the other order would swallow the deleted-conflict banner), hidden-latch with replay on visibility, and self-echo suppression (own writes stamp a 3 s map; echo-only batches defer one trailing verify run instead of refreshing per autosave).

Review hardening

The branch had two review rounds (an xhigh multi-agent /code-review + an independent review); everything verified landed in the last commit:

  • checkOpenNoteConflict re-validates after its stat await — a note switch mid-stat could raise a phantom conflict for the wrong note, and "Keep mine" would then save the open note's body over the statted one's file (cross-note corruption; pre-existing on the focus path, made hittable by the watcher).
  • refresh() keeps a deleted note's active alive while its banner is up — otherwise every keystroke typed under the banner was silently dropped and "Save as copy" persisted only pre-deletion content.
  • Reload notes / post-import use reload() (check-then-refresh); a bare refresh() swallowed the deleted-conflict on the web backends.
  • Echo suppression defers, never drops — a stamp false-positive (external dir-only event under an ancestor stamp; external rewrite of a just-saved note) now delays a refresh by a few seconds instead of losing it.
  • Watcher lifecycle leaks closed: page reloads (incl. WKWebView's silent crash-reload) reset the label's subscriptions via on_page_load; a notes_watch racing window destruction reaps itself; the drop-outside-mutex rule (a WatcherEntry's Drop joins the debouncer thread) is structural via a shared teardown helper.
  • Smaller: FSEvents rescan → refresh-everything; dot-named .md notes pass the filter (they're listed, so they must refresh); newest-wins list merge + addNote dedup (two refresh-vs-mutation races); import pauses the pipelines (withBulkWrites); idempotent disposer; shared temp-suffix consts; docs accuracy fixes.

Performance

Idle cost is ~zero (FSEvents is kernel-push; one sleeping thread per folder, count-independent of vault size). Reaction cost equals the existing focus-refresh, bounded by 400 ms native + 300 ms JS debounce + coalescing; the app's own saves never trigger it. Verified against a 796-note stress vault.

Tests

cargo test 25 ✓ (filter cases, refcount interleavings) + clippy clean; vitest 922 ✓ incl. new suites for the watch pipeline (external create/conflict/echo-defer/empty-paths/hidden-latch/late-dispose, keystrokes-survive-deleted-banner, reload ordering) and TauriNoteStore.watch (listen-before-invoke, dir filter, disposer, failure path); typecheck / lint / prettier / production build all green. Manual E2E over the demo + stress vaults (create/append/mkdir/mv, noise, floods, two-window, hidden-latch, workspace switches).

🤖 Generated with Claude Code

resure and others added 4 commits July 5, 2026 23:03
One debounced notify/FSEvents watcher per open folder in the Rust shell
(notes_watch/notes_unwatch, per-window-label refcounted subscriptions so
StrictMode's watch,watch,unwatch order stays subscribed), emitting
notes:changed {dir, paths} to each subscriber. The path filter passes .md
leaves, existing dirs (a Finder folder rename reports only dir paths), and
vanished paths; drops dot-entries, node_modules, root Attachments/, and
write temps; >64 paths or a watcher error collapses to an empty list
("refresh everything").

The seam grows an optional NoteStore.watch (desktop-only, like reveal);
useNotes subscribes once ready: conflict-check THEN refresh (reconcile
nulls a vanished active, so the other order would swallow the
deleted-conflict banner), 300 ms trailing debounce + in-flight coalescing,
self-echo suppression via a 3 s local-write stamp map (incl. ancestor
dirs; empty paths never suppressible), a saveInFlight gate against
phantom conflicts mid-save, and a hidden-latch that replays on
visibilitychange. Web backends keep the focus-driven refresh and gain a
"Reload notes" orb-menu item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README keeps features, a two-path Getting started (download the release /
run from source, with requirements folded in), doc pointers, and the
backlog. The architecture section, on-disk format, and known limitations
move to docs/architecture.md; the shortcut sheet moves to
docs/shortcuts.md, regenerated from the SHORTCUTS descriptor (it had
drifted — ctrl+R, cmd+shift+I, cmd+D, cmd+, were missing). CLAUDE.md
gains a Docs section with keep-in-sync rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from an xhigh /code-review round plus the external REVIEW.md,
all verified against the code before fixing:

Correctness
- checkOpenNoteConflict re-validates after its stat await (same note still
  open, no conflict/pending/save, baseline unchanged): a note switch mid-stat
  raised a phantom conflict for the WRONG note, whose banner actions key off
  conflict.id — "Keep mine" could save the open note's body over the statted
  one's file.
- refresh() keeps a vanished `active` alive while its deleted-conflict banner
  is up (conflictRef is now mirrored synchronously at every raise site so the
  guard can see it): reconcile nulled the pointer, after which edit() silently
  dropped every keystroke typed under the banner and "Save as copy" persisted
  only pre-deletion content.
- "Reload notes" (and the post-import refresh) go through a new reload() =
  conflict-check-then-refresh; the focus re-list now uses the same order. A
  bare refresh() swallowed the deleted-conflict banner on the web backends.
- Echo suppression defers instead of drops: an echo-only batch schedules ONE
  trailing verify run past the stamp window, so a false-positive (external
  dir-only event under an ancestor stamp, external rewrite of a just-saved
  note) delays a refresh instead of losing it — suppression is now genuinely
  a perf-only guard.
- Rust: an event on the watch root (FSEvents' queue-overflow rescan, all the
  debouncer leaves of it) now emits EMPTY paths = "refresh everything"
  instead of being filtered to nothing.
- Rust: watch subscriptions are drained on page load (a WKWebView crash-
  reload runs no JS cleanup and fires no Destroyed — each one leaked the
  label's refcount and pinned dead FSEvents watchers alive) and re-checked
  for window liveness after each notes_watch (the unlocked build gap could
  land a subscription for an already-destroyed window that nothing would
  ever drain). Both paths share remove_window_subscriptions(), which
  returns emptied entries for the caller to drop outside the mutex.
- Rust: dot-named `.md` notes pass the watch filter (the note walks list
  `.hidden.md` — only dot DIRS are skipped — so its events must flow too).
- refresh() merges newest-wins per row and addNote replaces-not-appends: a
  refresh whose list snapshot predates an in-flight save clobbered the fresh
  preview, and a concurrent refresh during create() doubled the new row.
- TauriNoteStore.watch's disposer is idempotent per handle (a double-dispose
  double-decremented the window's refcount).

Perf / cleanup
- Import runs under withBulkWrites(), holding the watcher/focus pipelines
  off (bulk writes bypass the echo stamps; every batch triggered a full
  mid-import re-list).
- createFolder stamps the store-returned (sanitized) path.
- Shared WRITE_TMP_SUFFIX / RENAME_TMP_SUFFIX consts + mirror comments on
  the TS minting sites; cross-referenced the two debounce constants
  (REVIEW.md #1); watch() failure now leaves a console.warn; "Reload notes"
  uses ArrowsRotateRight (REVIEW.md #4); dead test field removed.

Docs
- architecture.md: the metadata last-write-wins limitation applies to
  desktop windows too, not just browser tabs (the shell coordinates window
  assignment and note bodies, not the sidecar).
- Restored facts lost in the README slimming: system theme, search stays
  global under a folder scope, plain click opens links in preview.
- CLAUDE.md updated to the new invariants; REVIEW.md consumed and removed.

Verified: cargo test (25) + clippy clean, vitest 922 (2 new regression
tests: keystrokes survive under a deleted-conflict banner; reload() raises
the banner before refresh), typecheck, lint, prettier, production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@resure resure merged commit cbae762 into main Jul 6, 2026
2 checks passed
@resure resure deleted the live-file-watching branch July 6, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant